@brett_lamy/docstream-editor 0.7.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/package.json +2 -2
- package/src/editor/Editor.tsx +113 -5
- package/src/editor/attachments.tsx +354 -0
- package/src/editor/convert.ts +50 -2
- package/src/editor/nodes.tsx +2 -0
- package/src/editor/runtime.tsx +3 -0
- package/src/index.ts +3 -2
- package/src/styles.css +169 -8
package/README.md
CHANGED
|
@@ -75,6 +75,11 @@ export interface GitbookEditorProps {
|
|
|
75
75
|
onChange: (markdown: string) => void
|
|
76
76
|
onKeyDown?: (event: KeyboardEvent) => boolean
|
|
77
77
|
onPaste?: (event: ClipboardEvent) => boolean
|
|
78
|
+
imagePaste?: "inline" | "chip"
|
|
79
|
+
attachments?: EditorAttachment[]
|
|
80
|
+
onAttachmentAdd?: (attachment: EditorAttachment & { file: File }) => void
|
|
81
|
+
onAttachmentRemove?: (ids: string[]) => void
|
|
82
|
+
onAttachmentOpen?: (attachment: EditorAttachment) => void
|
|
78
83
|
}
|
|
79
84
|
```
|
|
80
85
|
|
|
@@ -82,6 +87,8 @@ export interface GitbookEditorProps {
|
|
|
82
87
|
- `onChange`: Called with serialized markdown whenever TipTap content changes.
|
|
83
88
|
- `onKeyDown` / `onPaste`: Optional host hooks that run before the built-in editor behavior;
|
|
84
89
|
return `true` when the application handled the event.
|
|
90
|
+
- `imagePaste`, `attachments`, `onAttachmentAdd`, `onAttachmentRemove`, `onAttachmentOpen`:
|
|
91
|
+
how pasted/dropped images are handled — see [Pasted and dropped images](#pasted-and-dropped-images).
|
|
85
92
|
|
|
86
93
|
The editor tracks the last markdown it emitted so normal controlled updates do not continuously reset the TipTap document. Passing a different external `markdown` value replaces the editor content.
|
|
87
94
|
|
|
@@ -132,6 +139,42 @@ dollar signs in ordinary prose never open a menu. Free-form entry is always offe
|
|
|
132
139
|
/>
|
|
133
140
|
```
|
|
134
141
|
|
|
142
|
+
## Pasted and dropped images
|
|
143
|
+
|
|
144
|
+
`imagePaste` controls how pasted or dropped image files enter the document. It runs after
|
|
145
|
+
your `onPaste` (return `true` there to take over). Clipboard data that also carries plain
|
|
146
|
+
text, such as a Word or Excel selection, still pastes as text.
|
|
147
|
+
|
|
148
|
+
- `"inline"` (default): an image block with the file as a data URL, the way it will render.
|
|
149
|
+
- `"chip"`: a compact attachment chip at the caret. The host stores the file. The chip
|
|
150
|
+
serializes as ``, shows a thumbnail and size, previews the
|
|
151
|
+
full image on hover, and opens it on click.
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
const [markdown, setMarkdown] = useState("")
|
|
155
|
+
const [attachments, setAttachments] = useState<EditorAttachment[]>([])
|
|
156
|
+
|
|
157
|
+
<GitbookEditor
|
|
158
|
+
markdown={markdown}
|
|
159
|
+
onChange={setMarkdown}
|
|
160
|
+
imagePaste="chip"
|
|
161
|
+
attachments={attachments}
|
|
162
|
+
// once per image: { id, name, src (data URL), size, type, file }
|
|
163
|
+
onAttachmentAdd={({ file, ...attachment }) => setAttachments((all) => [...all, attachment])}
|
|
164
|
+
// chips deleted, cut or cleared by the user
|
|
165
|
+
onAttachmentRemove={(ids) => setAttachments((all) => all.filter((a) => !ids.includes(a.id)))}
|
|
166
|
+
// optional; omit it to use the built-in full-screen viewer
|
|
167
|
+
onAttachmentOpen={(attachment) => openLightbox(attachment)}
|
|
168
|
+
/>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`EditorAttachment` is `{ id, name, src?, size?, type? }`. Chips find their image in
|
|
172
|
+
`attachments` by id, so `src` can be a data URL or an uploaded URL. To remove a chip
|
|
173
|
+
from outside the editor, call `editor.commands.removeAttachment(id)` (get the instance
|
|
174
|
+
from `onEditorReady`). Markdown images with an `attachment:` URL always load as chips,
|
|
175
|
+
whatever the mode. `serializeEditorMarkdown(ast)` is the serializer that writes them in
|
|
176
|
+
this form.
|
|
177
|
+
|
|
135
178
|
## Edit a referenced source file
|
|
136
179
|
|
|
137
180
|
`GitbookEditor` edits the Markdown composition, including a structured
|
|
@@ -182,6 +225,8 @@ Exports:
|
|
|
182
225
|
- `astToTiptap`
|
|
183
226
|
- `tiptapToAst`
|
|
184
227
|
- `PMNode`
|
|
228
|
+
- `EditorAttachment`
|
|
229
|
+
- `serializeEditorMarkdown`
|
|
185
230
|
- `SourceFileEditor`
|
|
186
231
|
- `SourceFileEditorProps`
|
|
187
232
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "TipTap editor for Docstream GitBook-style markdown documents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"./styles.css": "./src/styles.css"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@brett_lamy/docstream": "0.
|
|
36
|
+
"@brett_lamy/docstream": "1.0.0",
|
|
37
37
|
"gpu-lexer": "0.0.2",
|
|
38
38
|
"lucide-react": "^1.17.0"
|
|
39
39
|
},
|
package/src/editor/Editor.tsx
CHANGED
|
@@ -10,13 +10,23 @@ import {
|
|
|
10
10
|
Strikethrough,
|
|
11
11
|
} from "lucide-react"
|
|
12
12
|
|
|
13
|
-
import { parseMarkdown,
|
|
13
|
+
import { parseMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
|
|
14
14
|
import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
attachmentName,
|
|
17
|
+
collectAttachmentIds,
|
|
18
|
+
imageFilesFrom,
|
|
19
|
+
newAttachmentId,
|
|
20
|
+
readFileAsDataURL,
|
|
21
|
+
type EditorAttachment,
|
|
22
|
+
} from "./attachments"
|
|
23
|
+
import { astToTiptap, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
|
|
16
24
|
import { createGitbookExtensions, type ReferenceSources } from "./extensions"
|
|
17
25
|
import { EditorRuntimeProvider } from "./runtime"
|
|
18
26
|
import type { SlashItem } from "./slash-menu"
|
|
19
27
|
|
|
28
|
+
export type { EditorAttachment } from "./attachments"
|
|
29
|
+
|
|
20
30
|
export interface GitbookEditorProps {
|
|
21
31
|
/** Markdown source. When provided, the editor stays in sync with it (controlled). */
|
|
22
32
|
markdown?: string
|
|
@@ -50,6 +60,20 @@ export interface GitbookEditorProps {
|
|
|
50
60
|
onKeyDown?: (event: KeyboardEvent) => boolean
|
|
51
61
|
/** Handle clipboard content before the editor's built-in paste behavior. Return true when handled. */
|
|
52
62
|
onPaste?: (event: ClipboardEvent) => boolean
|
|
63
|
+
/**
|
|
64
|
+
* How pasted or dropped image files enter the document.
|
|
65
|
+
* "inline" (default) — an image block with the file as a data URL, as it will render.
|
|
66
|
+
* "chip" — a compact attachment chip at the caret; the host owns the file via `attachments`.
|
|
67
|
+
*/
|
|
68
|
+
imagePaste?: "inline" | "chip"
|
|
69
|
+
/** Chip mode: the host's attachment store. Chips look up their thumbnail, full image and size here by id. */
|
|
70
|
+
attachments?: EditorAttachment[]
|
|
71
|
+
/** Chip mode: fired once per pasted/dropped image with a fresh id and the file read as a data URL. Add it to `attachments`. */
|
|
72
|
+
onAttachmentAdd?: (attachment: EditorAttachment & { file: File }) => void
|
|
73
|
+
/** Chip mode: fired when chips disappear from the document (deleted, cut, cleared) with their ids. */
|
|
74
|
+
onAttachmentRemove?: (ids: string[]) => void
|
|
75
|
+
/** Chip mode: clicking (or Enter/Space on a focused) chip. Omit to open the built-in image viewer. */
|
|
76
|
+
onAttachmentOpen?: (attachment: EditorAttachment) => void
|
|
53
77
|
/** Receive the underlying TipTap editor instance (and null on teardown). */
|
|
54
78
|
onEditorReady?: (editor: TiptapEditor | null) => void
|
|
55
79
|
/** Client used to edit and preview `{% source-ref %}` files. */
|
|
@@ -138,6 +162,11 @@ export function GitbookEditor({
|
|
|
138
162
|
extensions,
|
|
139
163
|
onKeyDown,
|
|
140
164
|
onPaste,
|
|
165
|
+
imagePaste = "inline",
|
|
166
|
+
attachments,
|
|
167
|
+
onAttachmentAdd,
|
|
168
|
+
onAttachmentRemove,
|
|
169
|
+
onAttachmentOpen,
|
|
141
170
|
onEditorReady,
|
|
142
171
|
sourceClient,
|
|
143
172
|
sourcePreview = true,
|
|
@@ -152,6 +181,56 @@ export function GitbookEditor({
|
|
|
152
181
|
// ones from the last applied markdown and reattach them on serialize.
|
|
153
182
|
const citationsRef = useRef<CitationDef[] | undefined>(undefined)
|
|
154
183
|
const controlled = markdown !== undefined
|
|
184
|
+
// Ids of the attachment chips in the document, to report removals.
|
|
185
|
+
const attachmentIds = useRef<Set<string>>(new Set())
|
|
186
|
+
// Latest callbacks for handlers the editor captured at creation.
|
|
187
|
+
const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove })
|
|
188
|
+
latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove }
|
|
189
|
+
const editorRef = useRef<TiptapEditor | null>(null)
|
|
190
|
+
|
|
191
|
+
// Pasted/dropped image files: image blocks ("inline") or attachment chips ("chip").
|
|
192
|
+
const insertImageFiles = (files: File[], at?: number) => {
|
|
193
|
+
const ed = editorRef.current
|
|
194
|
+
if (!ed) return
|
|
195
|
+
const insert = (content: PMNode[]) => {
|
|
196
|
+
const chain = ed.chain().focus()
|
|
197
|
+
const pos = at === undefined ? undefined : Math.min(at, ed.state.doc.content.size)
|
|
198
|
+
;(pos === undefined ? chain.insertContent(content) : chain.insertContentAt(pos, content)).run()
|
|
199
|
+
}
|
|
200
|
+
if (latest.current.imagePaste === "chip") {
|
|
201
|
+
const items = files.map((file) => ({ file, id: newAttachmentId(), name: attachmentName(file) }))
|
|
202
|
+
// Keep chips off the preceding word: "done.[chip]" → "done. [chip]".
|
|
203
|
+
const $at = at === undefined ? ed.state.selection.$from : ed.state.doc.resolve(Math.min(at, ed.state.doc.content.size))
|
|
204
|
+
const before = $at.parent.isTextblock ? $at.parent.textBetween(Math.max(0, $at.parentOffset - 1), $at.parentOffset, "", "\uFFFC") : ""
|
|
205
|
+
const lead: PMNode[] = before && !/\s/.test(before) ? [{ type: "text", text: " " }] : []
|
|
206
|
+
insert([
|
|
207
|
+
...lead,
|
|
208
|
+
...items.flatMap(({ id, name }): PMNode[] => [
|
|
209
|
+
{ type: "gbAttachment", attrs: { id, name } },
|
|
210
|
+
{ type: "text", text: " " },
|
|
211
|
+
]),
|
|
212
|
+
])
|
|
213
|
+
for (const { file, id, name } of items) {
|
|
214
|
+
const base = { id, name, size: file.size, type: file.type, file }
|
|
215
|
+
readFileAsDataURL(file).then(
|
|
216
|
+
(src) => latest.current.onAttachmentAdd?.({ ...base, src }),
|
|
217
|
+
() => latest.current.onAttachmentAdd?.(base)
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
void Promise.all(
|
|
223
|
+
files.map((file) =>
|
|
224
|
+
readFileAsDataURL(file).then(
|
|
225
|
+
(src): PMNode => ({ type: "gbFigure", attrs: { src, alt: attachmentName(file), caption: "" } }),
|
|
226
|
+
() => null
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
).then((figures) => {
|
|
230
|
+
const content = figures.filter((f): f is PMNode => f !== null)
|
|
231
|
+
if (content.length && !ed.isDestroyed) insert(content)
|
|
232
|
+
})
|
|
233
|
+
}
|
|
155
234
|
|
|
156
235
|
const parseAndTrack = (md: string) => {
|
|
157
236
|
const doc = parseMarkdown(md)
|
|
@@ -171,19 +250,44 @@ export function GitbookEditor({
|
|
|
171
250
|
}),
|
|
172
251
|
editorProps: {
|
|
173
252
|
handleKeyDown: (_view, event) => onKeyDown?.(event) ?? false,
|
|
174
|
-
handlePaste: (
|
|
253
|
+
handlePaste: (view, event) => {
|
|
254
|
+
if (latest.current.onPaste?.(event)) return true
|
|
255
|
+
if (!view.editable || view.state.selection.$from.parent.type.spec.code) return false
|
|
256
|
+
const files = imageFilesFrom(event.clipboardData, { ignoreWithText: true })
|
|
257
|
+
if (!files.length) return false
|
|
258
|
+
event.preventDefault()
|
|
259
|
+
insertImageFiles(files)
|
|
260
|
+
return true
|
|
261
|
+
},
|
|
262
|
+
handleDrop: (view, event, _slice, moved) => {
|
|
263
|
+
if (moved || !view.editable) return false
|
|
264
|
+
const files = imageFilesFrom(event.dataTransfer)
|
|
265
|
+
if (!files.length) return false
|
|
266
|
+
event.preventDefault()
|
|
267
|
+
insertImageFiles(files, view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos)
|
|
268
|
+
return true
|
|
269
|
+
},
|
|
175
270
|
},
|
|
176
271
|
...(controlled ? { content: astToTiptap(parseAndTrack(markdown as string)) } : {}),
|
|
272
|
+
onCreate({ editor }) {
|
|
273
|
+
attachmentIds.current = collectAttachmentIds(editor.state.doc)
|
|
274
|
+
},
|
|
177
275
|
onUpdate({ editor }) {
|
|
276
|
+
const ids = collectAttachmentIds(editor.state.doc)
|
|
277
|
+
const removed = [...attachmentIds.current].filter((id) => !ids.has(id))
|
|
278
|
+
attachmentIds.current = ids
|
|
279
|
+
if (removed.length) latest.current.onAttachmentRemove?.(removed)
|
|
178
280
|
if (!onChange) return
|
|
179
281
|
const ast = tiptapToAst(editor.getJSON() as PMNode)
|
|
180
282
|
if (citationsRef.current?.length) ast.citations = citationsRef.current
|
|
181
|
-
const md =
|
|
283
|
+
const md = serializeEditorMarkdown(ast)
|
|
182
284
|
lastEmitted.current = md
|
|
183
285
|
onChange(md)
|
|
184
286
|
},
|
|
185
287
|
})
|
|
186
288
|
|
|
289
|
+
editorRef.current = editor ?? null
|
|
290
|
+
|
|
187
291
|
// Surface the editor instance to the host (for awareness, commands, etc.).
|
|
188
292
|
useEffect(() => {
|
|
189
293
|
onEditorReady?.(editor ?? null)
|
|
@@ -197,6 +301,8 @@ export function GitbookEditor({
|
|
|
197
301
|
const doc = parseMarkdown(markdown as string)
|
|
198
302
|
citationsRef.current = doc.citations
|
|
199
303
|
editor.commands.setContent(astToTiptap(doc), { emitUpdate: false })
|
|
304
|
+
// Chips replaced by the host's own markdown aren't "removed" by the user.
|
|
305
|
+
attachmentIds.current = collectAttachmentIds(editor.state.doc)
|
|
200
306
|
}, [editor, controlled, markdown])
|
|
201
307
|
|
|
202
308
|
const runtime = useMemo(() => ({
|
|
@@ -205,7 +311,9 @@ export function GitbookEditor({
|
|
|
205
311
|
sourceAutoSave,
|
|
206
312
|
...(onSourceSaved ? { onSourceSaved } : {}),
|
|
207
313
|
...(onSourceError ? { onSourceError } : {}),
|
|
208
|
-
|
|
314
|
+
...(attachments ? { attachments } : {}),
|
|
315
|
+
...(onAttachmentOpen ? { onAttachmentOpen } : {}),
|
|
316
|
+
}), [attachments, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
|
|
209
317
|
|
|
210
318
|
if (!editor) return null
|
|
211
319
|
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from "@tiptap/core"
|
|
2
|
+
import type { Node as ProseMirrorNode } from "@tiptap/pm/model"
|
|
3
|
+
import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from "@tiptap/react"
|
|
4
|
+
import { ImageIcon } from "lucide-react"
|
|
5
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"
|
|
6
|
+
import { createPortal } from "react-dom"
|
|
7
|
+
|
|
8
|
+
import { useEditorRuntime } from "./runtime"
|
|
9
|
+
|
|
10
|
+
/** A file the host owns, referenced from the document by an attachment chip. */
|
|
11
|
+
export interface EditorAttachment {
|
|
12
|
+
id: string
|
|
13
|
+
name: string
|
|
14
|
+
/** Full image URL (typically a data URL). Also used for the chip thumbnail. */
|
|
15
|
+
src?: string
|
|
16
|
+
/** Size in bytes. */
|
|
17
|
+
size?: number
|
|
18
|
+
/** MIME type. */
|
|
19
|
+
type?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The URL scheme attachment chips serialize to: ``. */
|
|
23
|
+
export const ATTACHMENT_SCHEME = "attachment:"
|
|
24
|
+
|
|
25
|
+
export function newAttachmentId(): string {
|
|
26
|
+
const rand =
|
|
27
|
+
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
28
|
+
? crypto.randomUUID().replace(/-/g, "").slice(0, 12)
|
|
29
|
+
: Math.random().toString(36).slice(2, 14)
|
|
30
|
+
return `att-${rand}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The name a pasted/dropped file gets in the document. Clipboard screenshots
|
|
35
|
+
* usually arrive as "image.png"; unnamed ones get that too. Characters that
|
|
36
|
+
* would break `` / `alt="…"` are replaced.
|
|
37
|
+
*/
|
|
38
|
+
export function attachmentName(file: File): string {
|
|
39
|
+
const name = (file.name || "image.png").replace(/[[\]"<>\r\n]/g, "_").trim()
|
|
40
|
+
return name || "image.png"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** "84 KB", "1.3 MB". */
|
|
44
|
+
export function formatBytes(bytes: number | undefined): string {
|
|
45
|
+
if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return ""
|
|
46
|
+
if (bytes < 1024) return `${bytes} B`
|
|
47
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
|
48
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
49
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readFileAsDataURL(file: File): Promise<string> {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const reader = new FileReader()
|
|
55
|
+
reader.onload = () => resolve(String(reader.result ?? ""))
|
|
56
|
+
reader.onerror = () => reject(reader.error ?? new Error(`Could not read ${file.name}`))
|
|
57
|
+
reader.readAsDataURL(file)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Image files carried by a paste or drop. Clipboard data that also has plain
|
|
63
|
+
* text (e.g. a Word/Excel selection, which ships a rendered PNG alongside the
|
|
64
|
+
* text) is left to the normal text paste.
|
|
65
|
+
*/
|
|
66
|
+
export function imageFilesFrom(data: DataTransfer | null, { ignoreWithText = false } = {}): File[] {
|
|
67
|
+
if (!data) return []
|
|
68
|
+
const files = Array.from(data.files ?? []).filter((f) => f.type.startsWith("image/"))
|
|
69
|
+
if (!files.length) return []
|
|
70
|
+
if (ignoreWithText && data.getData("text/plain").trim()) return []
|
|
71
|
+
return files
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Ids of every attachment chip in a document. */
|
|
75
|
+
export function collectAttachmentIds(doc: ProseMirrorNode): Set<string> {
|
|
76
|
+
const ids = new Set<string>()
|
|
77
|
+
doc.descendants((node) => {
|
|
78
|
+
if (node.type.name === "gbAttachment" && node.attrs.id) ids.add(String(node.attrs.id))
|
|
79
|
+
return node.isBlock || node.type.name === "doc"
|
|
80
|
+
})
|
|
81
|
+
return ids
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------- Hover card ----------
|
|
85
|
+
|
|
86
|
+
const HOVER_DELAY = 150
|
|
87
|
+
const HIDE_GRACE = 120
|
|
88
|
+
const GAP = 8
|
|
89
|
+
const MARGIN = 8
|
|
90
|
+
|
|
91
|
+
function AttachmentHoverCard({
|
|
92
|
+
anchor,
|
|
93
|
+
attachment,
|
|
94
|
+
}: {
|
|
95
|
+
anchor: HTMLElement
|
|
96
|
+
attachment: EditorAttachment
|
|
97
|
+
}) {
|
|
98
|
+
const cardRef = useRef<HTMLDivElement>(null)
|
|
99
|
+
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
|
100
|
+
|
|
101
|
+
const place = useCallback(() => {
|
|
102
|
+
const card = cardRef.current
|
|
103
|
+
if (!card) return
|
|
104
|
+
const a = anchor.getBoundingClientRect()
|
|
105
|
+
const { width, height } = card.getBoundingClientRect()
|
|
106
|
+
const vw = window.innerWidth
|
|
107
|
+
const vh = window.innerHeight
|
|
108
|
+
let top = a.top - GAP - height
|
|
109
|
+
if (top < MARGIN && a.bottom + GAP + height <= vh - MARGIN) top = a.bottom + GAP
|
|
110
|
+
top = Math.max(MARGIN, top)
|
|
111
|
+
const left = Math.min(Math.max(MARGIN, a.left + a.width / 2 - width / 2), Math.max(MARGIN, vw - MARGIN - width))
|
|
112
|
+
setPos({ top, left })
|
|
113
|
+
}, [anchor])
|
|
114
|
+
|
|
115
|
+
useLayoutEffect(place, [place, attachment.src])
|
|
116
|
+
|
|
117
|
+
const size = formatBytes(attachment.size)
|
|
118
|
+
const style: CSSProperties = pos
|
|
119
|
+
? { top: pos.top, left: pos.left }
|
|
120
|
+
: { top: 0, left: 0, visibility: "hidden" }
|
|
121
|
+
return createPortal(
|
|
122
|
+
<div ref={cardRef} className="gb-attachment-card" style={style} role="tooltip">
|
|
123
|
+
{attachment.src ? (
|
|
124
|
+
<img className="gb-attachment-card-img" src={attachment.src} alt={attachment.name} onLoad={place} />
|
|
125
|
+
) : (
|
|
126
|
+
<div className="gb-attachment-card-empty">
|
|
127
|
+
<ImageIcon className="size-5" />
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
<div className="gb-attachment-card-meta">
|
|
131
|
+
<span className="gb-attachment-card-name">{attachment.name}</span>
|
|
132
|
+
{size && <span className="gb-attachment-card-size"> · {size}</span>}
|
|
133
|
+
</div>
|
|
134
|
+
</div>,
|
|
135
|
+
document.body
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------- Built-in viewer ----------
|
|
140
|
+
|
|
141
|
+
function AttachmentViewer({ attachment, onClose }: { attachment: EditorAttachment; onClose: () => void }) {
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const onKey = (e: KeyboardEvent) => {
|
|
144
|
+
if (e.key === "Escape") {
|
|
145
|
+
e.preventDefault()
|
|
146
|
+
e.stopPropagation()
|
|
147
|
+
onClose()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
document.addEventListener("keydown", onKey, true)
|
|
151
|
+
return () => document.removeEventListener("keydown", onKey, true)
|
|
152
|
+
}, [onClose])
|
|
153
|
+
|
|
154
|
+
return createPortal(
|
|
155
|
+
<div
|
|
156
|
+
className="gb-attachment-viewer"
|
|
157
|
+
role="dialog"
|
|
158
|
+
aria-modal="true"
|
|
159
|
+
aria-label={attachment.name}
|
|
160
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
161
|
+
onClick={onClose}
|
|
162
|
+
>
|
|
163
|
+
{attachment.src ? (
|
|
164
|
+
<img className="gb-attachment-viewer-img" src={attachment.src} alt={attachment.name} />
|
|
165
|
+
) : (
|
|
166
|
+
<div className="gb-attachment-viewer-empty">
|
|
167
|
+
<ImageIcon className="size-6" />
|
|
168
|
+
{attachment.name}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>,
|
|
172
|
+
document.body
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------- Chip ----------
|
|
177
|
+
|
|
178
|
+
function AttachmentView({ node, selected, editor }: NodeViewProps) {
|
|
179
|
+
const { attachments, onAttachmentOpen } = useEditorRuntime()
|
|
180
|
+
const id = String(node.attrs.id ?? "")
|
|
181
|
+
const stored = attachments?.find((a) => a.id === id)
|
|
182
|
+
const attachment: EditorAttachment = { ...stored, id, name: stored?.name || String(node.attrs.name || "image") }
|
|
183
|
+
const size = formatBytes(attachment.size)
|
|
184
|
+
|
|
185
|
+
const chipRef = useRef<HTMLSpanElement>(null)
|
|
186
|
+
const [hovered, setHovered] = useState(false)
|
|
187
|
+
const [focused, setFocused] = useState(false)
|
|
188
|
+
const [dismissed, setDismissed] = useState(false)
|
|
189
|
+
const [viewing, setViewing] = useState(false)
|
|
190
|
+
const showTimer = useRef<number | undefined>(undefined)
|
|
191
|
+
const hideTimer = useRef<number | undefined>(undefined)
|
|
192
|
+
|
|
193
|
+
const clearTimers = () => {
|
|
194
|
+
window.clearTimeout(showTimer.current)
|
|
195
|
+
window.clearTimeout(hideTimer.current)
|
|
196
|
+
}
|
|
197
|
+
useEffect(() => clearTimers, [])
|
|
198
|
+
|
|
199
|
+
// A fresh selection of the chip re-arms a card dismissed with Escape.
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (selected) setDismissed(false)
|
|
202
|
+
}, [selected])
|
|
203
|
+
|
|
204
|
+
const cardOpen =
|
|
205
|
+
!viewing && !dismissed && (hovered || focused || (selected && editor.isFocused))
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!cardOpen) return
|
|
209
|
+
const hide = () => {
|
|
210
|
+
clearTimers()
|
|
211
|
+
setHovered(false)
|
|
212
|
+
setDismissed(true)
|
|
213
|
+
}
|
|
214
|
+
const onKey = (e: KeyboardEvent) => {
|
|
215
|
+
if (e.key === "Escape") hide()
|
|
216
|
+
}
|
|
217
|
+
window.addEventListener("scroll", hide, true)
|
|
218
|
+
document.addEventListener("keydown", onKey, true)
|
|
219
|
+
return () => {
|
|
220
|
+
window.removeEventListener("scroll", hide, true)
|
|
221
|
+
document.removeEventListener("keydown", onKey, true)
|
|
222
|
+
}
|
|
223
|
+
}, [cardOpen])
|
|
224
|
+
|
|
225
|
+
const open = () => {
|
|
226
|
+
clearTimers()
|
|
227
|
+
setHovered(false)
|
|
228
|
+
if (onAttachmentOpen) onAttachmentOpen(attachment)
|
|
229
|
+
else setViewing(true)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const closeViewer = useCallback(() => setViewing(false), [])
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<NodeViewWrapper as="span" className="gb-attachment-wrap" contentEditable={false}>
|
|
236
|
+
<span
|
|
237
|
+
ref={chipRef}
|
|
238
|
+
className="gb-attachment"
|
|
239
|
+
role="button"
|
|
240
|
+
tabIndex={0}
|
|
241
|
+
aria-label={size ? `${attachment.name}, ${size}` : attachment.name}
|
|
242
|
+
data-attachment-id={id}
|
|
243
|
+
onMouseEnter={() => {
|
|
244
|
+
window.clearTimeout(hideTimer.current)
|
|
245
|
+
setDismissed(false)
|
|
246
|
+
showTimer.current = window.setTimeout(() => setHovered(true), HOVER_DELAY)
|
|
247
|
+
}}
|
|
248
|
+
onMouseLeave={() => {
|
|
249
|
+
window.clearTimeout(showTimer.current)
|
|
250
|
+
hideTimer.current = window.setTimeout(() => setHovered(false), HIDE_GRACE)
|
|
251
|
+
}}
|
|
252
|
+
onFocus={() => {
|
|
253
|
+
setDismissed(false)
|
|
254
|
+
setFocused(true)
|
|
255
|
+
}}
|
|
256
|
+
onBlur={() => setFocused(false)}
|
|
257
|
+
// Keep the caret where it is — the chip is a button, not a text target.
|
|
258
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
259
|
+
onClick={(e) => {
|
|
260
|
+
e.preventDefault()
|
|
261
|
+
open()
|
|
262
|
+
}}
|
|
263
|
+
onKeyDown={(e) => {
|
|
264
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
265
|
+
e.preventDefault()
|
|
266
|
+
e.stopPropagation()
|
|
267
|
+
open()
|
|
268
|
+
}
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
<span className="gb-attachment-thumb">
|
|
272
|
+
{attachment.src ? (
|
|
273
|
+
<img src={attachment.src} alt="" draggable={false} />
|
|
274
|
+
) : (
|
|
275
|
+
<ImageIcon className="gb-attachment-thumb-icon" />
|
|
276
|
+
)}
|
|
277
|
+
</span>
|
|
278
|
+
<span className="gb-attachment-name">{attachment.name}</span>
|
|
279
|
+
{size && <span className="gb-attachment-size">{size}</span>}
|
|
280
|
+
</span>
|
|
281
|
+
{cardOpen && chipRef.current && <AttachmentHoverCard anchor={chipRef.current} attachment={attachment} />}
|
|
282
|
+
{viewing && <AttachmentViewer attachment={attachment} onClose={closeViewer} />}
|
|
283
|
+
</NodeViewWrapper>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
declare module "@tiptap/core" {
|
|
288
|
+
interface Commands<ReturnType> {
|
|
289
|
+
gbAttachment: {
|
|
290
|
+
/** Delete every attachment chip with this id. */
|
|
291
|
+
removeAttachment: (id: string) => ReturnType
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Inline attachment chip (``). The file itself lives in
|
|
298
|
+
* the host's attachment store; the node only carries the id and display name.
|
|
299
|
+
*/
|
|
300
|
+
export const GbAttachment = Node.create({
|
|
301
|
+
name: "gbAttachment",
|
|
302
|
+
group: "inline",
|
|
303
|
+
inline: true,
|
|
304
|
+
atom: true,
|
|
305
|
+
selectable: true,
|
|
306
|
+
draggable: false,
|
|
307
|
+
addAttributes() {
|
|
308
|
+
return {
|
|
309
|
+
id: { default: "" },
|
|
310
|
+
name: { default: "" },
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
parseHTML() {
|
|
314
|
+
return [
|
|
315
|
+
{
|
|
316
|
+
tag: "span[data-gb-attachment]",
|
|
317
|
+
getAttrs: (el) => ({
|
|
318
|
+
id: (el as HTMLElement).getAttribute("data-gb-attachment") ?? "",
|
|
319
|
+
name: (el as HTMLElement).getAttribute("data-name") ?? "",
|
|
320
|
+
}),
|
|
321
|
+
},
|
|
322
|
+
]
|
|
323
|
+
},
|
|
324
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
325
|
+
return [
|
|
326
|
+
"span",
|
|
327
|
+
mergeAttributes(HTMLAttributes, { "data-gb-attachment": node.attrs.id, "data-name": node.attrs.name }),
|
|
328
|
+
String(node.attrs.name),
|
|
329
|
+
]
|
|
330
|
+
},
|
|
331
|
+
renderText({ node }) {
|
|
332
|
+
return String(node.attrs.name)
|
|
333
|
+
},
|
|
334
|
+
addCommands() {
|
|
335
|
+
return {
|
|
336
|
+
removeAttachment:
|
|
337
|
+
(id) =>
|
|
338
|
+
({ tr, state, dispatch }) => {
|
|
339
|
+
const ranges: Array<[number, number]> = []
|
|
340
|
+
state.doc.descendants((node, pos) => {
|
|
341
|
+
if (node.type.name === this.name && node.attrs.id === id) ranges.push([pos, pos + node.nodeSize])
|
|
342
|
+
})
|
|
343
|
+
if (!ranges.length) return false
|
|
344
|
+
if (dispatch) {
|
|
345
|
+
for (const [from, to] of ranges.reverse()) tr.delete(from, to)
|
|
346
|
+
}
|
|
347
|
+
return true
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
addNodeView() {
|
|
352
|
+
return ReactNodeViewRenderer(AttachmentView)
|
|
353
|
+
},
|
|
354
|
+
})
|
package/src/editor/convert.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
plainText,
|
|
3
|
+
serializeMarkdown,
|
|
4
|
+
type Block,
|
|
5
|
+
type DocumentNode,
|
|
6
|
+
type Inline,
|
|
7
|
+
type ListItemNode,
|
|
8
|
+
} from "@brett_lamy/docstream/gitbook"
|
|
9
|
+
|
|
10
|
+
// Attachment chips live in markdown as images with an `attachment:<id>` URL.
|
|
11
|
+
const ATTACHMENT_SCHEME = "attachment:"
|
|
12
|
+
const isAttachmentSrc = (src: string | undefined) => !!src && src.startsWith(ATTACHMENT_SCHEME)
|
|
13
|
+
const attachmentPM = (src: string, alt: string | undefined): PMNode => ({
|
|
14
|
+
type: "gbAttachment",
|
|
15
|
+
attrs: { id: src.slice(ATTACHMENT_SCHEME.length), name: alt ?? "" },
|
|
16
|
+
})
|
|
2
17
|
|
|
3
18
|
// TipTap/ProseMirror JSON shape (loosely typed on purpose)
|
|
4
19
|
export interface PMNode {
|
|
@@ -23,6 +38,7 @@ function inlineToPM(nodes: Inline[]): PMNode[] {
|
|
|
23
38
|
attrs: { kind: n.kind, id: n.id, url: n.url ?? "", label: n.label ?? "" },
|
|
24
39
|
}
|
|
25
40
|
}
|
|
41
|
+
if (n.type === "image" && isAttachmentSrc(n.src)) return attachmentPM(n.src, n.alt)
|
|
26
42
|
if (n.type === "image") {
|
|
27
43
|
return {
|
|
28
44
|
type: "gbInlineImage",
|
|
@@ -135,6 +151,8 @@ function blockToPM(b: Block): PMNode {
|
|
|
135
151
|
content: b.columns.map((c) => ({ type: "gbColumn", content: blocksPM(c.children) })),
|
|
136
152
|
}
|
|
137
153
|
case "figure":
|
|
154
|
+
// A chip alone on its line parses as a figure; it is still a chip.
|
|
155
|
+
if (isAttachmentSrc(b.src)) return { type: "paragraph", content: [attachmentPM(b.src, b.alt)] }
|
|
138
156
|
return { type: "gbFigure", attrs: { src: b.src, alt: b.alt, caption: b.caption } }
|
|
139
157
|
case "list":
|
|
140
158
|
return {
|
|
@@ -199,8 +217,22 @@ export function astToTiptap(doc: DocumentNode): PMNode {
|
|
|
199
217
|
function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
|
|
200
218
|
if (!nodes) return []
|
|
201
219
|
return nodes
|
|
202
|
-
.filter(
|
|
220
|
+
.filter(
|
|
221
|
+
(n) =>
|
|
222
|
+
(n.type === "text" && n.text) ||
|
|
223
|
+
n.type === "gbInlineImage" ||
|
|
224
|
+
n.type === "gbReference" ||
|
|
225
|
+
n.type === "gbAttachment"
|
|
226
|
+
)
|
|
203
227
|
.map((n): Inline => {
|
|
228
|
+
if (n.type === "gbAttachment") {
|
|
229
|
+
const a = n.attrs ?? {}
|
|
230
|
+
return {
|
|
231
|
+
type: "image",
|
|
232
|
+
src: `${ATTACHMENT_SCHEME}${String(a.id ?? "")}`,
|
|
233
|
+
...(a.name ? { alt: String(a.name) } : {}),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
204
236
|
if (n.type === "gbReference") {
|
|
205
237
|
const a = n.attrs ?? {}
|
|
206
238
|
const kind =
|
|
@@ -398,3 +430,19 @@ function pmToBlocks(nodes: PMNode[] | undefined): Block[] {
|
|
|
398
430
|
export function tiptapToAst(doc: PMNode): DocumentNode {
|
|
399
431
|
return { type: "doc", children: pmToBlocks(doc.content) }
|
|
400
432
|
}
|
|
433
|
+
|
|
434
|
+
// docstream writes inline images as `<img src alt />`; attachment chips read
|
|
435
|
+
// better (and diff better) as plain markdown images.
|
|
436
|
+
const ATTACHMENT_IMG = /<img src="(attachment:[^"\s]+)"(?: alt="([^"]*)")? \/>/g
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Serialize an editor AST to GitBook markdown, writing attachment chips as
|
|
440
|
+
* ``. Use this instead of `serializeMarkdown` for
|
|
441
|
+
* editor output.
|
|
442
|
+
*/
|
|
443
|
+
export function serializeEditorMarkdown(doc: DocumentNode): string {
|
|
444
|
+
return serializeMarkdown(doc).replace(ATTACHMENT_IMG, (img: string, src: string, alt = "") =>
|
|
445
|
+
// `]` can't be escaped in `![alt]`; such names keep the <img> form.
|
|
446
|
+
alt.includes("]") ? img : ``
|
|
447
|
+
)
|
|
448
|
+
}
|
package/src/editor/nodes.tsx
CHANGED
|
@@ -32,6 +32,7 @@ import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
|
|
|
32
32
|
import { VideoEmbed } from "@brett_lamy/docstream/video"
|
|
33
33
|
import { SourceFileEditor } from "./SourceFileEditor"
|
|
34
34
|
import { useEditorRuntime } from "./runtime"
|
|
35
|
+
import { GbAttachment } from "./attachments"
|
|
35
36
|
|
|
36
37
|
function useDebouncedValue<T>(value: T, delay = 350): T {
|
|
37
38
|
const [debounced, setDebounced] = useState(value)
|
|
@@ -983,4 +984,5 @@ export const gitbookNodes = [
|
|
|
983
984
|
GbOpenapi,
|
|
984
985
|
GbInlineImage,
|
|
985
986
|
GbReference,
|
|
987
|
+
GbAttachment,
|
|
986
988
|
]
|
package/src/editor/runtime.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from "react"
|
|
2
2
|
|
|
3
3
|
import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
|
|
4
|
+
import type { EditorAttachment } from "./attachments"
|
|
4
5
|
|
|
5
6
|
export interface EditorRuntimeOptions {
|
|
6
7
|
sourceClient?: SourceReferenceClient
|
|
@@ -8,6 +9,8 @@ export interface EditorRuntimeOptions {
|
|
|
8
9
|
sourceAutoSave?: boolean | number
|
|
9
10
|
onSourceSaved?: (snapshot: SourceFileSnapshot) => void
|
|
10
11
|
onSourceError?: (error: Error) => void
|
|
12
|
+
attachments?: EditorAttachment[]
|
|
13
|
+
onAttachmentOpen?: (attachment: EditorAttachment) => void
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
const EditorRuntimeContext = createContext<EditorRuntimeOptions>({})
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { GitbookEditor } from "./editor/Editor"
|
|
2
|
-
export type { GitbookEditorProps } from "./editor/Editor"
|
|
3
|
-
export { astToTiptap, tiptapToAst } from "./editor/convert"
|
|
2
|
+
export type { GitbookEditorProps, EditorAttachment } from "./editor/Editor"
|
|
3
|
+
export { astToTiptap, tiptapToAst, serializeEditorMarkdown } from "./editor/convert"
|
|
4
4
|
export type { PMNode } from "./editor/convert"
|
|
5
5
|
|
|
6
6
|
// Building blocks for composing a custom editor (e.g. with Yjs collaboration
|
|
@@ -8,6 +8,7 @@ export type { PMNode } from "./editor/convert"
|
|
|
8
8
|
export { createGitbookExtensions, getGitbookSchema, GbTable } from "./editor/extensions"
|
|
9
9
|
export type { GitbookExtensionOptions, ReferenceSources } from "./editor/extensions"
|
|
10
10
|
export { gitbookNodes, GbCodeBlock, GbReference } from "./editor/nodes"
|
|
11
|
+
export { GbAttachment, formatBytes } from "./editor/attachments"
|
|
11
12
|
export { SlashMenu, createSlashMenu, SLASH_ITEMS } from "./editor/slash-menu"
|
|
12
13
|
export type { SlashItem } from "./editor/slash-menu"
|
|
13
14
|
export { createReferenceSuggestion, resolveReferenceOptions } from "./editor/reference-menu"
|
package/src/styles.css
CHANGED
|
@@ -583,14 +583,15 @@
|
|
|
583
583
|
}
|
|
584
584
|
.gb-code > pre code { background: none; padding: 0; font-size: inherit; color: inherit; }
|
|
585
585
|
/* gpu-lexer span classes (palette shared with docstream's HighlightedCode). */
|
|
586
|
-
|
|
587
|
-
.gb-code .gb-syntax-
|
|
588
|
-
.gb-code .gb-syntax-
|
|
589
|
-
.gb-code .gb-syntax-
|
|
590
|
-
.gb-code .gb-syntax-
|
|
591
|
-
.gb-code .gb-syntax-
|
|
592
|
-
.gb-code .gb-syntax-
|
|
593
|
-
.gb-code .gb-syntax-
|
|
586
|
+
/* Same palette and custom properties as Docstream's .docs-tok-*: follows the host color-scheme. */
|
|
587
|
+
.gb-code .gb-syntax-comment { color: var(--docs-tok-comment, light-dark(#6E6E7A, #8A8A98)); font-style: italic; }
|
|
588
|
+
.gb-code .gb-syntax-string { color: var(--docs-tok-string, light-dark(#23803A, #A5D6A7)); }
|
|
589
|
+
.gb-code .gb-syntax-number { color: var(--docs-tok-number, light-dark(#C2410C, #F78C6C)); }
|
|
590
|
+
.gb-code .gb-syntax-keyword { color: var(--docs-tok-keyword, light-dark(#8839C4, #C792EA)); }
|
|
591
|
+
.gb-code .gb-syntax-type { color: var(--docs-tok-type, light-dark(#A15C00, #FFCB6B)); }
|
|
592
|
+
.gb-code .gb-syntax-function { color: var(--docs-tok-function, light-dark(#2F5BD3, #82AAFF)); }
|
|
593
|
+
.gb-code .gb-syntax-constant { color: var(--docs-tok-constant, light-dark(#C8283F, #F07178)); }
|
|
594
|
+
.gb-code .gb-syntax-operator { color: var(--docs-tok-operator, light-dark(#0B7285, #89DDFF)); }
|
|
594
595
|
|
|
595
596
|
/* ── Updates / changelog (mirrors .docs-updates) ────────── */
|
|
596
597
|
.gb-updates { margin: 0.85em 0; border-top: 1px solid var(--gb-border); }
|
|
@@ -859,3 +860,163 @@
|
|
|
859
860
|
outline: 2px solid var(--gb-primary);
|
|
860
861
|
outline-offset: 1px;
|
|
861
862
|
}
|
|
863
|
+
|
|
864
|
+
/* ---- Attachment chips (pasted images in imagePaste="chip" mode) ---- */
|
|
865
|
+
|
|
866
|
+
.gb-attachment-wrap {
|
|
867
|
+
display: inline;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.gb-attachment {
|
|
871
|
+
display: inline-flex;
|
|
872
|
+
align-items: center;
|
|
873
|
+
gap: 5px;
|
|
874
|
+
max-width: 100%;
|
|
875
|
+
padding: 1px 7px 1px 3px;
|
|
876
|
+
border: 1px solid var(--gb-border);
|
|
877
|
+
border-radius: 7px;
|
|
878
|
+
background: var(--gb-muted);
|
|
879
|
+
color: var(--gb-text);
|
|
880
|
+
font-size: 0.85em;
|
|
881
|
+
line-height: 1.5;
|
|
882
|
+
vertical-align: middle;
|
|
883
|
+
cursor: pointer;
|
|
884
|
+
user-select: none;
|
|
885
|
+
transition: border-color 120ms ease, background-color 120ms ease;
|
|
886
|
+
}
|
|
887
|
+
.gb-attachment:hover {
|
|
888
|
+
border-color: var(--gb-muted-foreground);
|
|
889
|
+
}
|
|
890
|
+
.gb-attachment:focus {
|
|
891
|
+
outline: none;
|
|
892
|
+
}
|
|
893
|
+
.gb-attachment:focus-visible,
|
|
894
|
+
.ProseMirror-selectednode .gb-attachment {
|
|
895
|
+
outline: 2px solid var(--gb-primary);
|
|
896
|
+
outline-offset: 1px;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
.gb-attachment-thumb {
|
|
900
|
+
display: inline-flex;
|
|
901
|
+
flex-shrink: 0;
|
|
902
|
+
align-items: center;
|
|
903
|
+
justify-content: center;
|
|
904
|
+
width: 20px;
|
|
905
|
+
height: 16px;
|
|
906
|
+
overflow: hidden;
|
|
907
|
+
border-radius: 3px;
|
|
908
|
+
background: var(--gb-border);
|
|
909
|
+
color: var(--gb-muted-foreground);
|
|
910
|
+
}
|
|
911
|
+
.gb-attachment-thumb img {
|
|
912
|
+
display: block;
|
|
913
|
+
width: 100%;
|
|
914
|
+
height: 100%;
|
|
915
|
+
object-fit: cover;
|
|
916
|
+
}
|
|
917
|
+
.gb-attachment-thumb-icon {
|
|
918
|
+
width: 12px;
|
|
919
|
+
height: 12px;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
.gb-attachment-name {
|
|
923
|
+
overflow: hidden;
|
|
924
|
+
font-weight: 500;
|
|
925
|
+
white-space: nowrap;
|
|
926
|
+
text-overflow: ellipsis;
|
|
927
|
+
max-width: 22ch;
|
|
928
|
+
}
|
|
929
|
+
.gb-attachment-size {
|
|
930
|
+
flex-shrink: 0;
|
|
931
|
+
color: var(--gb-muted-foreground);
|
|
932
|
+
font-variant-numeric: tabular-nums;
|
|
933
|
+
white-space: nowrap;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/* Portaled to <body>, outside .gb — tokens resolve from the host directly. */
|
|
937
|
+
.gb-attachment-card {
|
|
938
|
+
position: fixed;
|
|
939
|
+
z-index: 1000;
|
|
940
|
+
box-sizing: border-box;
|
|
941
|
+
max-width: calc(100vw - 16px);
|
|
942
|
+
padding: 6px;
|
|
943
|
+
border: 1px solid var(--border, #e5e7eb);
|
|
944
|
+
border-radius: 12px;
|
|
945
|
+
background: var(--card, #ffffff);
|
|
946
|
+
color: var(--card-foreground, var(--foreground, #111827));
|
|
947
|
+
font-family: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
|
|
948
|
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.08);
|
|
949
|
+
pointer-events: none;
|
|
950
|
+
animation: gb-attachment-in 120ms ease-out;
|
|
951
|
+
}
|
|
952
|
+
.gb-attachment-card-img {
|
|
953
|
+
display: block;
|
|
954
|
+
max-width: 360px;
|
|
955
|
+
max-height: 280px;
|
|
956
|
+
width: auto;
|
|
957
|
+
height: auto;
|
|
958
|
+
object-fit: contain;
|
|
959
|
+
border-radius: 7px;
|
|
960
|
+
background: var(--muted, #f4f4f5);
|
|
961
|
+
}
|
|
962
|
+
.gb-attachment-card-empty {
|
|
963
|
+
display: flex;
|
|
964
|
+
align-items: center;
|
|
965
|
+
justify-content: center;
|
|
966
|
+
width: 160px;
|
|
967
|
+
height: 100px;
|
|
968
|
+
border-radius: 7px;
|
|
969
|
+
background: var(--muted, #f4f4f5);
|
|
970
|
+
color: var(--muted-foreground, #6b7280);
|
|
971
|
+
}
|
|
972
|
+
.gb-attachment-card-meta {
|
|
973
|
+
max-width: 360px;
|
|
974
|
+
padding: 6px 4px 1px;
|
|
975
|
+
overflow: hidden;
|
|
976
|
+
font-size: 12px;
|
|
977
|
+
line-height: 1.4;
|
|
978
|
+
white-space: nowrap;
|
|
979
|
+
text-overflow: ellipsis;
|
|
980
|
+
}
|
|
981
|
+
.gb-attachment-card-name { font-weight: 500; }
|
|
982
|
+
.gb-attachment-card-size { color: var(--muted-foreground, #6b7280); }
|
|
983
|
+
|
|
984
|
+
.gb-attachment-viewer {
|
|
985
|
+
position: fixed;
|
|
986
|
+
inset: 0;
|
|
987
|
+
z-index: 1001;
|
|
988
|
+
display: flex;
|
|
989
|
+
align-items: center;
|
|
990
|
+
justify-content: center;
|
|
991
|
+
background: rgba(0, 0, 0, 0.72);
|
|
992
|
+
cursor: zoom-out;
|
|
993
|
+
animation: gb-attachment-fade 140ms ease-out;
|
|
994
|
+
}
|
|
995
|
+
.gb-attachment-viewer-img {
|
|
996
|
+
max-width: 90vw;
|
|
997
|
+
max-height: 90vh;
|
|
998
|
+
object-fit: contain;
|
|
999
|
+
border-radius: 8px;
|
|
1000
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
|
|
1001
|
+
}
|
|
1002
|
+
.gb-attachment-viewer-empty {
|
|
1003
|
+
display: flex;
|
|
1004
|
+
flex-direction: column;
|
|
1005
|
+
align-items: center;
|
|
1006
|
+
gap: 10px;
|
|
1007
|
+
color: #ffffff;
|
|
1008
|
+
font-size: 14px;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
@keyframes gb-attachment-in {
|
|
1012
|
+
from { opacity: 0; transform: translateY(2px); }
|
|
1013
|
+
to { opacity: 1; transform: none; }
|
|
1014
|
+
}
|
|
1015
|
+
@keyframes gb-attachment-fade {
|
|
1016
|
+
from { opacity: 0; }
|
|
1017
|
+
to { opacity: 1; }
|
|
1018
|
+
}
|
|
1019
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1020
|
+
.gb-attachment-card,
|
|
1021
|
+
.gb-attachment-viewer { animation: none; }
|
|
1022
|
+
}
|